Skip to content

fix(run): return structured conversation history and preserve multi-artifact tool results#2743

Open
mike-inkeep wants to merge 6 commits intomainfrom
stack/history_shape
Open

fix(run): return structured conversation history and preserve multi-artifact tool results#2743
mike-inkeep wants to merge 6 commits intomainfrom
stack/history_shape

Conversation

@mike-inkeep
Copy link
Contributor

@mike-inkeep mike-inkeep commented Mar 18, 2026

Summary

  • getConversationHistoryWithCompression now returns MessageSelect[] instead of a pre-formatted string. Callers that need prompt text use the shared formatMessagesAsConversationHistory helper (wired from buildConversationHistory).
  • Tool-result rewriting for the ledger no longer keeps only one artifact per toolCallId: all matching artifacts are inlined as compact references, with a single Tool call args: line when present.
  • Message content.parts handling treats kind and legacy type as equivalent in reconstructMessageText and when persisting streamed assistant turns in ExecutionHandler.
  • Runtime DAL: getConversationHistory / context-window truncation are typed as MessageSelect[]; synthetic truncation summary rows are fully shaped MessageSelect records. MessageMetadata gains optional compressionType.

Key decisions

  • Structured return vs string at the API boundary: Compression and artifact substitution mutate or filter MessageSelect[]; formatting to <conversation_history> XML is explicit and reusable (formatMessagesAsConversationHistory) instead of being baked into getConversationHistoryWithCompression.
  • Multi-artifact tool calls: toolCallId maps to an array of artifacts so none are dropped when the ledger has multiple rows for the same id.

Manual QA

No manual QA performed.

Test plan

  • Unit/integration tests updated: Agent.test, conversations.test, conversations.artifact-replacement.test, ledgerArtifacts.test

@changeset-bot
Copy link

changeset-bot bot commented Mar 18, 2026

⚠️ No Changeset found

Latest commit: 393705c

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@vercel
Copy link

vercel bot commented Mar 18, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agents-api Ready Ready Preview, Comment Mar 24, 2026 5:28pm
agents-docs Ready Ready Preview, Comment Mar 24, 2026 5:28pm
agents-manage-ui Ready Ready Preview, Comment Mar 24, 2026 5:28pm

Request Review

Copy link
Contributor

@pullfrog pullfrog bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The return-type refactor from string to MessageSelect[] is well-structured — separation of fetching/compressing vs. formatting is a good direction, and the multi-artifact grouping fix is correct. Three areas need attention before merging: a behavioral inconsistency between getFormattedConversationHistory and the new formatMessagesAsConversationHistory, a createdAt regression in the summary message, and missing test coverage for two new code paths.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow runpullfrog.com𝕏

Comment on lines 923 to 959
@@ -937,8 +947,13 @@ function formatMessagesAsConversationHistory(messages: any[]): string {
roleLabel = msg.role || 'system';
}

return `${roleLabel}: """${reconstructMessageText(msg)}"""`;
const reconstructedMessage = reconstructMessageText(msg);
if (!reconstructedMessage) {
return null;
}
return `${roleLabel}: """${reconstructedMessage}"""`;
})
.filter((line): line is string => line !== null)
.join('\n');

return `<conversation_history>\n${formattedHistory}\n</conversation_history>\n`;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The newly exported formatMessagesAsConversationHistory differs from the inline formatting in getFormattedConversationHistory (around line 376) in two meaningful ways:

  1. This function uses reconstructMessageText(msg) (handles multi-part content with artifact refs), while the other uses msg.content.text directly — silently dropping artifact reference tags from multi-part messages.
  2. This function filters out messages with empty reconstructed text, while the other includes them (producing role: """""" entries).

Since getFormattedConversationHistory is still actively called from AgentSession.ts, these inconsistencies produce different conversation history formats depending on the code path. Consider refactoring getFormattedConversationHistory to delegate to this exported function.

Comment on lines +244 to +268
const referenceMessage = messageHistory[0];
const summaryMessage: MessageSelect = {
id: `summary-${getConversationId()}`,
tenantId: referenceMessage.tenantId,
projectId: referenceMessage.projectId,
conversationId: referenceMessage.conversationId,
role: 'system',
fromSubAgentId: null,
toSubAgentId: null,
fromExternalAgentId: null,
toExternalAgentId: null,
fromTeamAgentId: null,
toTeamAgentId: null,
content: {
text: `[Previous conversation history truncated - ${i + 1} earlier messages]`,
},
visibility: 'system',
messageType: 'chat',
createdAt: messageHistory[0].createdAt,
taskId: null,
parentMessageId: null,
a2aTaskId: null,
a2aSessionId: null,
metadata: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createdAt changed from messageHistory[0].createdAt to new Date().toISOString(). The summary message now has a timestamp after all the messages it summarizes, rather than being anchored to the start of the conversation. The array ordering is still correct (it's unshifted), but if any downstream code sorts or filters by createdAt, the summary would sort to the end. The previous behavior was more semantically correct — consider reverting to referenceMessage.createdAt.

Comment on lines +174 to +179
const result = await getConversationHistoryWithCompression(baseParams);
const toolResult = result.find((msg) => msg.messageType === 'tool-result');
const toolResultText = toolResult?.content?.text ?? '';

expect(toolResultText).toContain('id: art-1');
expect(toolResultText).toContain('id: art-2');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test verifies both artifact IDs are present but doesn't assert the \n join between them. If the join separator were accidentally changed to empty string or space, this test would still pass. Consider:

expect(toolResultText).toMatch(/id: art-1[\s\S]*\n[\s\S]*id: art-2/);

return parts
.map((part: any) => {
if (part.type === 'text') {
const partKind = part.kind ?? part.type;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The part.kind ?? part.type fallback handles both A2A protocol shape ({ kind: 'text' }) and legacy shape ({ type: 'text' }). The existing reconstructMessageText tests only cover { type: ... } parts — there's no test coverage for the { kind: ... } shape. Add a test case to verify the fallback works.

Comment on lines +950 to +956
const reconstructedMessage = reconstructMessageText(msg);
if (!reconstructedMessage) {
return null;
}
return `${roleLabel}: """${reconstructedMessage}"""`;
})
.filter((line): line is string => line !== null)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

formatMessagesAsConversationHistory now filters out messages where reconstructMessageText returns empty string — this is new behavior (previously all messages were formatted). A message with only non-text/non-artifact parts (e.g. file parts) would be silently dropped. This needs a direct test, and it's worth confirming this is the desired behavior for all message types.

Copy link
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review Summary

(1) Total Issues | Risk: Low

🟡 Minor (1) 🟡

Inline Comments:

  • 🟡 Minor: conversations.ts:510 Args duplicated for each artifact sharing a toolCallId

💭 Consider (2) 💭

💭 1) conversations.ts:903 Defensive fallback for part.kind ?? part.type lacks documentation

Issue: The change from part.type to part.kind ?? part.type suggests two different part schemas exist, but there's no documentation of when each occurs.
Why: The canonical types use kind. If type is a legacy format, a comment would clarify intent.
Fix: Add inline comment explaining the variants, or log occurrences if type is unexpected.
Refs: utility.ts:91-96 (canonical kind-based schema)

💭 2) conversations.ts:950-956 Behavioral change - empty messages now filtered out

Issue: New implementation filters out messages with empty reconstructed text. Previously these appeared as roleLabel: """""".
Why: This is arguably an improvement but goes beyond the stated refactoring goal.
Fix: Document this as intentional and consider adding a test case.

Inline Comments:

  • 💭 Consider: conversations.ts:903 Defensive kind ?? type fallback
  • 💭 Consider: conversations.ts:950-956 Empty message filtering behavior change

💡 APPROVE WITH SUGGESTIONS

Summary: This is a well-structured refactoring that improves type safety by returning MessageSelect[] from getConversationHistoryWithCompression and moving formatting to call sites. The multi-artifact support for shared toolCallId values is a valuable fix. The one Minor issue (args duplication) is a small optimization opportunity, and the Consider items are documentation/testing suggestions rather than blocking concerns.

The PR is ready to merge. Nice work on improving the type boundaries! 🎉

Discarded (3)
Location Issue Reason Discarded
conversations.artifact-replacement.test.ts:147 Test doesn't verify newline-joining format Nitpick - the core assertion (both artifacts present) is correct
conversations.test.ts Missing tests for kind property in reconstructMessageText Valid but downgraded - defensive code for compatibility, not a bug risk
conversations.artifact-replacement.test.ts:35 Test helper missing MessageSelect fields Pre-existing pattern in test fixtures, low impact
Reviewers (5)
Reviewer Returned Main Findings Consider While You're Here Inline Comments Pending Recs Discarded
pr-review-tests 4 0 0 0 0 0 3
pr-review-precision 3 0 2 0 1 0 0
pr-review-standards 0 0 0 0 0 0 0
pr-review-types 0 0 0 0 0 0 0
pr-review-errors 0 0 0 0 0 0 0
Total 7 0 2 0 1 0 3

const artifactParts = [
`Artifact: "${artifact.name ?? artifact.artifactId}" (id: ${artifact.artifactId})`,
];
if (argsStr) artifactParts.push(`args: ${argsStr}`);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Minor: Args duplicated for each artifact sharing a toolCallId

Issue: When multiple artifacts share the same toolCallId, the argsStr (computed once from message metadata at lines 492-495) gets appended to every artifact's reference string inside the map. Since all artifacts for the same tool call came from the same invocation, they have identical toolArgs, resulting in the same args: {...} appearing N times in the output.

Why: This unnecessarily bloats token count in the conversation history. For tool calls that produce multiple artifacts (e.g., multiple images or documents), the args string could be repeated 2-10x.

Fix: Consider moving args to a single prefix line before the artifact list:

const argsLine = argsStr ? `Tool call args: ${argsStr}\n` : '';
const artifactRefs = relatedArtifacts.map((artifact) => {
  // ... build artifact-specific parts WITHOUT args
});
return {
  ...msg,
  content: { text: argsLine + artifactRefs.join('\n') },
};

Refs:

return parts
.map((part: any) => {
if (part.type === 'text') {
const partKind = part.kind ?? part.type;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💭 Consider: Defensive fallback for part.kind ?? part.type

Issue: This fallback suggests there are two different part schemas in play: the canonical MessageContent.parts type (which uses kind per the schema definitions) and some other format that uses type. The fallback is reasonable defensive code, but there's no documentation explaining when each variant occurs.

Why: Without clarity on the source of type-keyed parts, this could mask upstream inconsistencies rather than solve a real polymorphism need. The canonical types in utility.ts and a2a.ts both use kind.

Fix: If both are legitimate variants (e.g., legacy data or external formats), add a brief inline comment:

// Support both 'kind' (canonical schema) and 'type' (legacy/external format)
const partKind = part.kind ?? part.type;

If type is never expected, consider logging when encountered to track occurrences.

Comment on lines +950 to +956
const reconstructedMessage = reconstructMessageText(msg);
if (!reconstructedMessage) {
return null;
}
return `${roleLabel}: """${reconstructedMessage}"""`;
})
.filter((line): line is string => line !== null)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💭 Consider: Behavioral change - empty messages now filtered out

Issue: The new implementation filters out messages with empty reconstructed text (returning null then filtering). The previous implementation would include these as roleLabel: """""". This is arguably an improvement, but goes beyond the stated refactoring goal.

Why: This could affect downstream consumers that rely on message boundaries (e.g., compression logic counting messages) or debugging traces. If intentional, it's worth documenting.

Fix: If intentional, consider adding a test case validating the filtering behavior and noting this in the PR description as a deliberate improvement.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tim-inkeep does this seem reasonable? It's now filtering out empty messages, whereas before it would sometimes have messages like user: "". I did this because a user a submitting an image without text would sometimes confuse the model, which only saw the empty user text and replied with something like "how can I help you?"

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think this is fine

@pullfrog
Copy link
Contributor

pullfrog bot commented Mar 23, 2026

TL;DR — Shifts getConversationHistoryWithCompression from returning a pre-formatted string to returning raw MessageSelect[], moving the formatting responsibility to the call site via the now-exported formatMessagesAsConversationHistory. Also fixes a data-loss bug where multiple artifacts sharing a toolCallId collapsed to a single reference, and tightens anyMessageSelect typing across the history stack.

Key changes

  • getConversationHistoryWithCompression returns MessageSelect[] instead of string — call sites in buildConversationHistory now explicitly call formatMessagesAsConversationHistory to produce the final string, decoupling data retrieval from presentation.
  • Fix multi-artifact toolCallId grouping — the artifact-replacement logic now collects all artifacts per toolCallId into an array instead of keeping only the last one, preserving every reference in the compressed output.
  • Export formatMessagesAsConversationHistory — promoted from a private function to a public export so call sites can format MessageSelect[] on demand; filters out messages that reconstruct to empty strings and short-circuits on empty input.
  • Replace any with MessageSelect across history APIsgetScopedHistory, applyContextWindowManagement, getConversationHistory, and related locals are now properly typed, including a fully-typed summary placeholder message.
  • Normalize kind/type part detectionreconstructMessageText and executionHandler now use part.kind ?? part.type to support both the current field name and legacy shapes.
  • Deduplicate formatting in getFormattedConversationHistory — replaced 29 lines of inline role-label formatting logic with a single call to formatMessagesAsConversationHistory.
  • Add compressionType to MessageMetadata — new optional field on the metadata type to support compression-related annotations.

Summary | 9 files | 5 commits | base: mainstack/history_shape


Return structured messages instead of pre-formatted strings

Before: getConversationHistoryWithCompression called formatMessagesAsConversationHistory internally and returned a string.
After: It returns MessageSelect[]; buildConversationHistory calls the formatter explicitly.

This decouples history retrieval from serialization, letting future consumers of the history data (e.g. token-counting, streaming, or alternate formats) operate on structured messages without re-parsing a string. The summary placeholder created by applyContextWindowManagement is now a fully-typed MessageSelect object populated from a referenceMessage, satisfying the complete type contract instead of using a loosely-typed bag.

conversations.ts · conversation-history.ts · runtime/conversations.ts


Multi-artifact grouping per toolCallId

Before: artifactsByToolCallId was a Map<string, Artifact> — when multiple artifacts shared a toolCallId, only the last one survived.
After: It is a Map<string, Artifact[]>, and the replacement template iterates all related artifacts, joining their references with newlines.

A new test case (preserves all artifact references when multiple artifacts share a toolCallId) covers the fix end-to-end. The ledgerArtifacts data-access test suite also gains a returns multiple artifacts for the same toolCallId case validating the underlying 1:N relationship.

conversations.ts · conversations.artifact-replacement.test.ts · ledgerArtifacts.test.ts


kind/type normalization in message part detection

Before: Code checked part.kind or part.type inconsistently — some paths only checked one field.
After: Both reconstructMessageText and executionHandler use part.kind ?? part.type, ensuring legacy data with type and current data with kind are handled uniformly.

A dedicated test (uses part.type when kind is omitted (legacy shape)) validates the backward-compatible fallback. The executionHandler extracts this pattern into a getResponsePartKind() helper for reuse across text/data detection.

conversations.ts · executionHandler.ts · conversations.test.ts

Pullfrog  | View workflow run | Triggered by Pullfrogpullfrog.com𝕏

Copy link
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review Summary

(0) Total Issues | Risk: Low

Re-review scope: Changes since last review (8eea5bf88a06). The delta consists of a merge commit bringing in PDF attachment support from origin/main (PR #2709).

Delta Review Summary

Files with delta changes:

  • conversation-history.ts: Added mapFileToAiSdkContentPart helper for PDF/image handling, renamed imagePartsfileParts
  • conversations.ts: Updated createMessage API to use { scopes, data } structure
  • Agent.test.ts: Added PDF test + mock path rename

Findings: No new issues meeting the severity/confidence thresholds were identified in the delta changes. The PDF support code is well-structured with proper type handling, defensive null returns for unsupported file types, and appropriate warning logging.

🕐 Pending Recommendations (5)

Prior review feedback that remains applicable to the original PR changes:


✅ APPROVE

Summary: The delta changes from the merge commit are clean. PDF attachment support is correctly integrated with the existing file handling infrastructure. Prior review feedback from the first review run remains applicable to the original PR changes — see Pending Recommendations above. 🎉

Reviewers (2)
Reviewer Returned Main Findings Consider While You're Here Inline Comments Pending Recs Discarded
pr-review-standards 0 0 0 0 0 0 0
pr-review-tests 3 0 0 0 0 0 3
Total 3 0 0 0 0 0 3

Note: Test coverage findings (unsupported file types, filename metadata, URI path) were assessed as MEDIUM/LOW confidence and discarded per review criteria.

@github-actions github-actions bot deleted a comment from claude bot Mar 23, 2026
@itoqa
Copy link

itoqa bot commented Mar 24, 2026

Ito Test Report ❌

7 test cases ran. 1 failed, 6 passed.

Overall, the unified run executed 7 test cases with 6 passes and 1 failure (plus several verification runs with no executable cases), indicating generally solid behavior with one confirmed production defect. Key findings were that auth setup and run-domain gates behaved correctly (web_client app creation returned 201, PoW was required and successfully minted anon tokens when solved, and unauthorized origins were blocked with 403), while chat edge checks for 10 parallel requests and a ~50k prompt were stable, but first-turn requests incorrectly inject an empty <conversation_history> wrapper (medium severity, likely introduced by this PR) causing no-history semantics drift.

❌ Failed (1)
Category Summary Screenshot
Edge 🟠 First-turn requests can still inject an empty <conversation_history> wrapper when prior history is empty. N/A
🟠 Empty conversation history wrapper injected on first turn
  • What failed: Expected no conversation-history payload on first turn, but the formatter still emits <conversation_history>\n\n</conversation_history>\n, which is treated as non-empty and injected.
  • Impact: First-turn prompts can carry an empty synthetic wrapper that should not exist, adding avoidable prompt noise and behavior drift. This can cause brittle instruction-following in edge flows that depend on exact no-history semantics.
  • Introduced by this PR: Yes – this PR modified the relevant code
  • Steps to reproduce:
    1. Create a brand-new conversation with no prior messages.
    2. Call the chat endpoint for the first turn and inspect how conversation history is built.
    3. Observe that an empty <conversation_history> wrapper is still added to initial model messages.
  • Code analysis: Inspected conversation-history retrieval/formatting and message assembly paths. getConversationHistoryWithCompression correctly returns an empty array when there is no history, but formatMessagesAsConversationHistory always wraps output, and buildInitialMessages only checks trim() !== '', so the wrapper is injected even when there are zero historical messages.
  • Why this is likely a bug: The production path composes [] history into a non-empty wrapper string and then unconditionally injects it, which contradicts intended first-turn no-history behavior.

Relevant code:

agents-api/src/domains/run/data/conversations.ts (lines 462-464)

if (!messagesToFormat.length) {
  return [];
}

agents-api/src/domains/run/data/conversations.ts (lines 925-962)

export function formatMessagesAsConversationHistory(messages: MessageSelect[]): string {
  const formattedHistory = messages
    .map((msg: MessageSelect) => {
      // ...role formatting...
      const reconstructedMessage = reconstructMessageText(msg);
      if (!reconstructedMessage) {
        return null;
      }
      return `${roleLabel}: """${reconstructedMessage}"""`;
    })
    .filter((line): line is string => line !== null)
    .join('\n');

  return `<conversation_history>\n${formattedHistory}\n</conversation_history>\n`;
}

agents-api/src/domains/run/agents/generation/conversation-history.ts (lines 124-126)

if (conversationHistory.trim() !== '') {
  messages.push({ role: 'user', content: conversationHistory });
}
✅ Passed (6)
Category Summary Screenshot
Adversarial Valid PoW from an unauthorized origin is denied, preventing token issuance. ADV-4
Auth Created prerequisite project/app setup and successfully created a web_client app via manage API (HTTP 201). AUTH-1
Auth Anonymous-session creation without a PoW solution is rejected with the expected bad-request error. AUTH-2
Auth Solved PoW challenge mints an anonymous session token successfully, with expected JWT claims. AUTH-3
Edge 10 parallel non-stream requests succeeded with HTTP 200 and conversation remained readable. EDGE-4
Edge A ~50k-character prompt completed with HTTP 200 and no uncaught 500/internal error. EDGE-5

Commit: a96c949

View Full Run


Tell us how we did: Give Ito Feedback

Copy link
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review Summary

(1) Total Issues | Risk: Medium

Re-review scope: Changes since last review (a96c94951ea5). The delta includes 6 commits with improvements that address prior feedback.

Delta Review Summary

Prior Feedback Addressed in This Delta:

  • Args duplication fixedargsStr is now output once as a prefix argsLine, not per-artifact
  • createdAt regression fixed — Summary message now uses referenceMessage.createdAt instead of new Date()
  • Type safety improvedtoolCallId now has typeof === 'string' guard; reconstructMessageText properly typed
  • Test coverage for kind ?? type — Added test for legacy part.type shape fallback

Delta Code Quality:
The delta changes are clean improvements that tighten types and fix prior issues. The getResponsePartKind helper in executionHandler.ts nicely encapsulates the kind ?? type fallback pattern.

🟠⚠️ Major (1) 🟠⚠️

Inline Comments:

  • 🟠 Major: conversations.ts:901 Empty array produces non-empty wrapper (Ito test failure root cause)

💭 Consider (2) 💭

💭 1) conversations.test.ts Missing test for content.text fallback when parts yield empty

Issue: The implementation at reconstructMessageText (line 898) returns fromParts || textFallback, but there's no test verifying the fallback path when parts exist but produce empty output.
Why: This behavioral change was noted in prior reviews but lacks explicit test coverage documenting the contract.
Fix: Add test: it('falls back to content.text when parts yield empty') with fixture { content: { text: 'fallback', parts: [{ kind: 'text' }] } }.

💭 2) conversations.test.ts Missing test for mixed kind/type parts in same message

Issue: No test verifies behavior when a message contains BOTH legacy type and modern kind parts.
Why: Edge case during data migration; implementation handles it per-part but it's untested.
Fix: Add test with mixed fixture: parts: [{ kind: 'text', text: 'a' }, { type: 'text', text: 'b' }].

🕐 Pending Recommendations (4)

Prior review feedback that may still apply:

  • 💭 conversations.ts:879 Defensive kind ?? type fallback — consider adding inline comment explaining the two schemas
  • 💭 conversations.ts:929-934 Empty message filtering behavior change — document as intentional (relates to mike-inkeep's question to tim-inkeep)
  • 🟠 conversations.ts:937 Behavioral inconsistency between getFormattedConversationHistory and formatMessagesAsConversationHistory (pullfrog) — now unified, but empty array handling differs
  • 🟠 conversations.artifact-replacement.test.ts:147 Test doesn't verify newline join between artifacts (pullfrog) — now includes /\]\s*\n\n\[/ assertion

🚫 REQUEST CHANGES

Summary: The delta changes are solid improvements that address prior feedback. However, there's one Major issue that needs fixing before merge: formatMessagesAsConversationHistory([]) returns a non-empty wrapper string instead of empty string, causing the Ito test failure where first-turn prompts incorrectly include an empty <conversation_history> block. This is a simple fix (early return for empty array).

Discarded (2)
Location Issue Reason Discarded
executionHandler.ts:37 getResponsePartKind lacks direct unit test Low priority - trivial helper, integration coverage sufficient
utility.ts:119 compressionType addition undocumented Additive type change, no behavioral impact
Reviewers (2)
Reviewer Returned Main Findings Consider While You're Here Inline Comments Pending Recs Discarded
pr-review-standards 0 0 0 0 0 0 0
pr-review-tests 4 0 2 0 0 0 2
Orchestrator 1 1 0 0 1 0 0
Total 5 1 2 0 1 0 2

Note: The Major finding was identified by cross-referencing the Ito test failure with the code path analysis.

}

function formatMessagesAsConversationHistory(messages: any[]): string {
export function formatMessagesAsConversationHistory(messages: MessageSelect[]): string {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 MAJOR: Empty array produces non-empty wrapper (Ito test failure root cause)

Issue: When messages is empty, this function returns <conversation_history>\n\n</conversation_history>\n instead of an empty string. This wrapper passes the trim() !== '' check in buildInitialMessages, causing an empty history wrapper to be injected into first-turn prompts.

Why: This was flagged by the Ito test suite: "First-turn requests can still inject an empty <conversation_history> wrapper when prior history is empty." This adds noise to first-turn prompts and can cause behavioral drift in edge cases that depend on no-history semantics.

Fix: Return empty string for empty input:

export function formatMessagesAsConversationHistory(messages: MessageSelect[]): string {
  if (messages.length === 0) {
    return '';
  }
  // ... rest of function
}

Refs:

@github-actions github-actions bot deleted a comment from claude bot Mar 24, 2026
Copy link
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review Summary

(0) Total Issues | Risk: Low

Re-review scope: Changes since last review (37a910e7169a). The delta includes 2 commits with fixes that address the blocking issue from the prior review.

Delta Review Summary

Prior Blocking Issue Resolved:

  • Empty wrapper injection FIXEDformatMessagesAsConversationHistory([]) now returns '' instead of <conversation_history>\n\n</conversation_history>\n. Two guards added: early return for empty array (line 903-905) and check for empty formattedHistory after filtering (lines 942-944). This directly addresses the Ito test failure.

Additional Improvements in Delta:

  • Inline comment added — Line 879-880 now documents the kind ?? type fallback: "Canonical MessageContent.parts use kind; older persisted rows and some external payloads might still use type."
  • Test coverage added — New describe('formatMessagesAsConversationHistory') block with 3 tests covering empty array, all-empty-reconstruction, and normal wrapping behavior.

Code Quality:
The delta changes are minimal and focused. The fix is correctly placed — both the early return and the post-filter check are necessary to handle (1) empty input and (2) input that produces no reconstructable text.

🕐 Pending Recommendations (2)

Lower-priority items from prior reviews that remain applicable but are non-blocking:

  • 💭 conversations.test.ts Missing test for content.text fallback when parts exist but yield empty — would document the fromParts || textFallback contract
  • 💭 conversations.test.ts Missing test for mixed kind/type parts in same message — edge case during data migration

✅ APPROVE

Summary: The delta resolves the blocking Ito test failure. The empty-wrapper injection bug is fixed with proper guards at both the array-level and post-reconstruction-filtering levels. The implementation is clean, the fix is well-tested, and the inline documentation for the kind ?? type fallback addresses prior feedback. Ship it! 🚀

Reviewers (1)
Reviewer Returned Main Findings Consider While You're Here Inline Comments Pending Recs Discarded
orchestrator 2 0 0 0 0 2 0
Total 2 0 0 0 0 2 0

Note: Delta was small enough to review directly without dispatching sub-reviewers.

@mike-inkeep mike-inkeep changed the title Conversation history returns MessageSelect[] and formats at call site fix(run): Conversation history as structured messages + artifact / part-shape fixes Mar 24, 2026
@github-actions github-actions bot deleted a comment from claude bot Mar 24, 2026
@mike-inkeep mike-inkeep changed the title fix(run): Conversation history as structured messages + artifact / part-shape fixes fix(run): return structured conversation history and preserve multi-artifact tool results Mar 24, 2026
@itoqa
Copy link

itoqa bot commented Mar 24, 2026

Ito Test Report ✅

15 test cases ran. 15 passed.

Across the unified QA run, 15 executed test cases passed with 0 failures, and two additional sessions reported no includable verification results. The most important confirmed behaviors were stable conversation continuity and persistence across /run/api/chat and OpenAI-compatible /run/v1/chat/completions (including SSE [DONE]), clean first-turn handling without conversation-history leakage, correct and safe history/tool-result formatting (artifact substitution, no-artifact preservation, truncation limits, and wrapper-text injection resistance), robust non-500 handling for malformed payloads and header abuse, resilience through refresh/navigation/mobile/abort/deep-link flows without duplication or corruption, and cross-context authorization isolation with no secret leakage.

✅ Passed (15)
Category Summary Screenshot
Adversarial Confirmed truncation guards limit tool args/artifact summaries and prevent oversized raw payload inclusion. ADV-1
Adversarial Confirmed injection-like wrapper text is treated as literal message content, not structural history markup. ADV-2
Adversarial Replayed a context-A conversationId using context-B token; no DELTA-77 leakage appeared in context-B response. ADV-3
Edge Malformed data-part input returned controlled handling and conversation remained usable on follow-up. EDGE-1
Edge Re-test showed refresh-mid-stream resume preserved conversation continuity without corruption. EDGE-3
Edge Repeated submit plus back/forward churn persisted one logical user turn per submit. EDGE-4
Edge Mobile viewport execution preserved two-turn continuity and conversation persistence. EDGE-5
Journey Deep-link continuation with an existing conversationId remained stable after refresh. JOURNEY-1
Logic First-turn /run/api/chat on a fresh conversationId returned 200 with clean output and no <conversation_history> leakage. LOGIC-1
Logic Confirmed multi-artifact substitution preserves all artifact references for a shared toolCallId. LOGIC-3
Logic Confirmed tool-result content remains unchanged when no matching artifact exists. LOGIC-4
Rapid Abort-then-resubmit sequence completed with stable history and no retry duplication. RAPID-2
Happy-path Two sequential /run/api/chat calls with the same conversationId returned 200 and preserved ALPHA-17 context on turn 2. ROUTE-1
Happy-path Two-turn /run/v1/chat/completions flow returned 200 with OpenAI-style SSE + [DONE] and recalled BRAVO-29 on turn 2. ROUTE-2
Sec Invalid client headers and malformed header-map input were handled safely without 500 responses. SEC-1

Commit: 393705c

View Full Run


Tell us how we did: Give Ito Feedback

Comment on lines +950 to +956
const reconstructedMessage = reconstructMessageText(msg);
if (!reconstructedMessage) {
return null;
}
return `${roleLabel}: """${reconstructedMessage}"""`;
})
.filter((line): line is string => line !== null)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think this is fine

@tim-inkeep
Copy link
Contributor

@mike-inkeep I would just want to make sure that all functionality still works changing to parts, but i think it will

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants